β

Как стать автором
Курсы по фронтенду
Да случится мэтч: оцени совместимость с IT в ритейле

Моя лентаВсе потоки

4.05

Оценка

875.82

Рейтинг

VK
Технологии, которые объединяют

Подписаться

AloneCoder 9 авг 2019 в 13:18

Разбираемся с интерфейсами в Go

10 мин

147K

Блог компании VKGo*Анализ и проектирование систем*Программирование*Качество кода*

Туториал

Перевод

Автор оригинала: Alex Edwards

В последние несколько месяцев я проводил исследование, в котором спрашивал людей, что им трудно понять в Go. И заметил, что в ответах регулярно упоминалась концепция интерфейсов. Go был первым языком с интерфейсами, который я использовал, и я помню, что в то время эта концепция казалась сильно запутанной. И в этом руководстве я хочу сделать вот что:

Человеческим языком объяснить, что такое интерфейсы.

Объяснить, чем они полезны и как вы можете использовать их в своём коде.

Поговорить о том, что такое interface{} (пустой интерфейс).

И пройтись по нескольким полезным типам интерфейсов, которые вы можете найти в стандартной библиотеке.

Так что такое интерфейс?

Интерфейсный тип в Go — это своего рода определение. Он определяет и описывает конкретные методы, которые должны быть у какого-то другого типа.

Одним из интерфейсных типов из стандартной библиотеки является интерфейс fmt.Stringer:

type Stringer interface {
String() string
}

Мы говорим, что что-то удовлетворяет этому интерфейсу (или реализует этот интерфейс), если у этого «что-то» есть метод с конкретным сигнатурным строковым значением String().

Например, тип Book удовлетворяет интерфейсу, потому что у него есть строковый метод String():

type Book struct {
Title string
Author string
}

func (b Book) String() string {
return fmt.Sprintf("Book: %s - %s", b.Title, b.Author)
}

Неважно, каким типом является Book или что он делает. Важно лишь, что у него есть метод под названием String(), который возвращает строковое значение.

А вот другой пример. Тип Count тоже удовлетворяет интерфейсу fmt.Stringer, потому что у него есть метод с тем же сигнатурным строковым значением String().

type Count int

func (c Count) String() string {
return strconv.Itoa(int(c))
}

Здесь важно понять, что у нас есть два разных типа Book и Count, которые действуют по-разному. Но их объединяет то, что они оба удовлетворяют интерфейсу fmt.Stringer.

Можете посмотреть на это с другой стороны. Если вы знаете, что объект удовлетворяет интерфейсу fmt.Stringer, то можете считать, что у него есть метод с сигнатурным строковым значением String(), которое вы можете вызывать.

А теперь самое важное.

Когда вы видите в Go объявление (переменной, параметра функции или поля структуры), имеющее интерфейсный тип, вы можете использовать объект любого типа, пока он удовлетворяет интерфейсу.

Допустим, у нас есть функция:

func WriteLog(s fmt.Stringer) {
log.Println(s.String())
}

Поскольку WriteLog() использует в объявлении параметра интерфейсный тип fmt.Stringer, мы можем передавать любой объект, удовлетворяющий интерфейсу fmt.Stringer. Например, можем передать типы Book и Count, которые создали ранее в методе WriteLog(), и код будет нормально работать.

Кроме того, поскольку передаваемый объект удовлетворяет интерфейсу fmt.Stringer, мы знаем, что у него есть строковый метод String(), который может быть безопасно вызван функцией WriteLog().

Давайте соберём всё сказанное в один пример, демонстрирующий мощь интерфейсов.

package main

import (
"fmt"
"strconv"
"log"
)

// Объявляем тип Book, который удовлетворяет интерфейсу fmt.Stringer.
type Book struct {
Title string
Author string
}

func (b Book) String() string {
return fmt.Sprintf("Book: %s - %s", b.Title, b.Author)
}

// Объявляем тип Count, который удовлетворяет интерфейсу fmt.Stringer.
type Count int

func (c Count) String() string {
return strconv.Itoa(int(c))
}

// Объявляем функцию WriteLog(), которая берёт любой объект,
// удовлетворяющий интерфейсу fmt.Stringer в виде параметра.
func WriteLog(s fmt.Stringer) {
log.Println(s.String())
}

func main() {
// Инициализируем объект Book и передаём в WriteLog().
book := Book{"Alice in Wonderland", "Lewis Carrol"}
WriteLog(book)

// Инициализируем объект Count и передаём в WriteLog().
count := Count(3)
WriteLog(count)
}

Это круто. В основной функции мы создали разные типы Book и Count, но передали их одной функции WriteLog(). А та вызвала соответствующие функции String() и записала результаты в журнал.

Если выполните код, то получите подобный результат:

2009/11/10 23:00:00 Book: Alice in Wonderland - Lewis Carrol
2009/11/10 23:00:00 3

Не будем на этом подробно останавливаться. Главное, что нужно запомнить: используя интерфейсный тип в объявлении функции WriteLog(), мы сделали функцию безразличной (или гибкой) к типу принимаемого объекта. Важно лишь то, какие у него методы.

Чем полезны интерфейсы?

Есть целый ряд причин, по которым вы можете начать использовать интерфейсы в Go. И по моему опыту, самые важные из них такие:

Интерфейсы помогают уменьшить дублирование, то есть количество шаблонного кода.

Они облегчают использование в модульных тестах заглушек вместо реальных объектов.

Будучи архитектурным инструментом, интерфейсы помогают отвязывать части вашей кодовой базы.

Рассмотрим подробнее эти способы использования интерфейсов.

Уменьшение количества шаблонного кода

Пусть у нас есть структура Customer, содержащая какие-то данные о клиенте. В одной части кода мы хотим записывать эту информацию в bytes.Buffer, а в другой части хотим записывать данные о клиенте в os.File на диске. Но, в обоих случаях, мы хотим сначала сериализовать структуру Сustomer в JSON.

При таком сценарии мы можем с помощью интерфейсов Go уменьшить количество шаблонного кода.

В Go есть интерфейсный тип io.Writer:

type Writer interface {
Write(p []byte) (n int, err error)
}

И мы можем воспользоваться тем, что bytes.Buffer и тип os.File удовлетворяют этому интерфейсу, поскольку имеют, соответственно, методы bytes.Buffer.Write() и os.File.Write().

Простая реализация:

package main

import (
"encoding/json"
"io"
"log"
"os"
)

// Создаём тип Customer.
type Customer struct {
Name string
Age int
}

// Реализуем метод WriteJSON, который берёт io.Writer в виде параметра.
// Он отправляет структуру Сustomer в JSON, и если всё отрабатывает
// успешно, то вызывается соответствующий метод Write() из io.Writer.
func (c *Customer) WriteJSON(w io.Writer) error {
js, err := json.Marshal(c)
if err != nil {
return err
}

_, err = w.Write(js)
return err
}

func main() {
// Инициализируем структуру Customer.
c := &Customer{Name: "Alice", Age: 21}

// Затем с помощью Buffer можем вызвать метод WriteJSON
var buf bytes.Buffer
err := c.WriteJSON(buf)
if err != nil {
log.Fatal(err)
}

// или воспользоваться файлом.
f, err := os.Create("/tmp/customer")
if err != nil {
log.Fatal(err)
}
defer f.Close()

err = c.WriteJSON(f)
if err != nil {
log.Fatal(err)
}
}

Конечно, это лишь выдуманный пример (мы можем по-разному структурировать код, чтобы добиться того же результата). Но он хорошо иллюстрирует преимущества использования интерфейсов: мы можем один раз создать метод Customer.WriteJSON() и вызывать его каждый раз, когда нужно записать во что-то, удовлетворяющее интерфейсу io.Writer.

Но если вы новичок в Go, у вас возникнет пара вопросов: «Как узнать, что интерфейс io.Writer вообще существует? И как заранее узнать, что ему удовлетворяют bytes.Buffer и os.File?»

Боюсь, простого решения тут нет. Вам просто нужно набраться опыта, познакомиться с интерфейсами и разными типами из стандартной библиотеки. В этом поможет чтение документации по этой библиотеке и просмотр чужого кода. А для быстрого ознакомления я добавил в конец статьи самые полезные типы интерфейсные типы.

Но даже если вы не используете интерфейсы из стандартной библиотеки, ничто не мешает вам создать и использовать собственные интерфейсные типы. Об этом мы поговорим ниже.

Модульное тестирование и заглушки

Чтобы понять, как интерфейсы помогают в модульном тестировании, давайте рассмотрим пример посложнее.

Допустим, у вас есть магазин, и вы храните в PostgreSQL информацию о продажах и количестве клиентов. Вы хотите написать код, вычисляющий долю продаж (удельное количество продаж на одного клиента) за последние сутки, округлённую до двух знаков после запятой.

Минимальная реализация будет выглядеть так:

// Файл: main.go
package main

import (
"fmt"
"log"
"time"
"database/sql"
_ "github.com/lib/pq"
)

type ShopDB struct {
*sql.DB
}

func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count)
return count, err
}

func (sdb *ShopDB) CountSales(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count)
return count, err
}

func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
log.Fatal(err)
}
defer db.Close()

shopDB := &ShopDB{db}
sr, err := calculateSalesRate(shopDB)
if err != nil {
log.Fatal(err)
}
fmt.Printf(sr)
}

func calculateSalesRate(sdb *ShopDB) (string, error) {
since := time.Now().Sub(24 * time.Hour)

sales, err := sdb.CountSales(since)
if err != nil {
return "", err
}

customers, err := sdb.CountCustomers(since)
if err != nil {
return "", err
}

rate := float64(sales) / float64(customers)
return fmt.Sprintf("%.2f", rate), nil
}

Теперь мы хотим создать модульный тест для функции calculateSalesRate(), чтобы проверить корректность вычислений.

Сейчас это проблематично. Нам понадобится настроить тестовый экземпляр PostgreSQL, а также создать и удалить скрипты для наполнения базы фальшивыми данными. Придётся проделать много работы, если мы действительно хотим протестировать наши вычисления.

И на помощь приходят интерфейсы!

Мы создадим собственный интерфейсный тип, описывающий методы CountSales() и CountCustomers(), на которые опирается функция calculateSalesRate(). Затем обновим сигнатуру calculateSalesRate(), чтобы использовать этот интерфейсный тип в качестве параметра вместо прописанного типа *ShopDB.

Вот так:

// Файл: main.go
package main

import (
"fmt"
"log"
"time"
"database/sql"
_ "github.com/lib/pq"
)

// Создаём свой интерфейс ShopModel. Он прекрасно подходит для
// интерфейса с описанием нескольких методов, и он должен описывать
// входные параметры-типы, а также типы возвращаемых значений.
type ShopModel interface {
CountCustomers(time.Time) (int, error)
CountSales(time.Time) (int, error)
}

// Тип ShopDB удовлетворяет новому интерфейсу ShopModel, потому что
// у него есть два необходимых метода -- CountCustomers() и CountSales().
type ShopDB struct {
*sql.DB
}

func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count)
return count, err
}

func (sdb *ShopDB) CountSales(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count)
return count, err
}

func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
log.Fatal(err)
}
defer db.Close()

shopDB := &ShopDB{db}

sr := calculateSalesRate(shopDB)
fmt.Printf(sr)
}

// Заменим это для использования интерфейсного типа ShopModel в виде параметра
// вместо прописанного типа *ShopDB.
func calculateSalesRate(sm ShopModel) string {
since := time.Now().Sub(24 * time.Hour)

sales, err := sm.CountSales(since)
if err != nil {
return "", err
}

customers, err := sm.CountCustomers(since)
if err != nil {
return "", err
}

rate := float64(sales) / float64(customers)
return fmt.Sprintf("%.2f", rate), nil
}

После того как мы это сделали, нам будет просто создать заглушку, которая удовлетворяет интерфейсу ShopModel. Затем можно использовать её в ходе модульного тестирования корректной работы математической логики в функции calculateSalesRate(). Вот так:

// Файлы: main_test.go
package main

import (
"testing"
)

type MockShopDB struct{}

func (m *MockShopDB) CountCustomers() (int, error) {
return 1000, nil
}

func (m *MockShopDB) CountSales() (int, error) {
return 333, nil
}

func TestCalculateSalesRate(t *testing.T) {
// Инициализируем заглушку.
m := &MockShopDB{}
// Передаём заглушку в функцию calculateSalesRate().
sr := calculateSalesRate(m)

// Проверяем, соответствует ли возвращаемое значение ожиданиям на основе
// фальшивых входных данных.
exp := "0.33"
if sr != exp {
t.Fatalf("got %v; expected %v", sr, exp)
}
}

Теперь запускаем тест и всё прекрасно работает.

Архитектура приложения

В предыдущем примере мы видели, как можно использовать интерфейсы для отвязки определённых частей кода от использования конкретных типов. Например, функции calculateSalesRate() совершенно не важно, что вы ей передадите, лишь бы оно удовлетворяло интерфейсу ShopModel.

Вы можете расширить эту идею и создавать в крупных проектах целые «отвязанные» уровни.

Допустим, вы создаёте веб-приложение, взаимодействующее с базой данных. Если сделаете интерфейс, описывающий определённые методы для взаимодействия с БД, то сможете ссылаться на него вместо конкретного типа через HTTP-обработчики. Поскольку HTTP-обработчики ссылаются только на интерфейс, это поможет отвязать друг от друга HTTP-уровень и уровень взаимодействия с базой данных. Будет проще работать с уровнями независимо, и в будущем вы сможете заменять какие-то уровни, не влияя на работу остальных.

Я написал об этом паттерне в одном из предыдущих постов, там больше подробностей и приведены практические примеры.

Что такое пустой интерфейс?

Если вы уже какое-то время программируете на Go, то наверняка сталкивались с пустым интерфейсным типом interface{}. Попробую объяснить, что это такое. В начале этой статьи я написал:

Интерфейсный тип в Go — это своего рода определение. Он определяет и описывает конкретные методы, которые должны быть у какого-то другого типа.

Пустой интерфейсный тип не описывает методы. У него нет правил. И поэтому любой объект удовлетворяет пустому интерфейсу.

По сути, пустой интерфейсный тип interface{} — своего рода джокер. Если вы встретили его в объявлении (переменной, параметра функции или поля структуры), то можете использовать объект любого типа.

Рассмотрим код:

package main

import "fmt"

func main() {
person := make(map[string]interface{}, 0)

person["name"] = "Alice"
person["age"] = 21
person["height"] = 167.64

fmt.Printf("%+v", person)
}

Здесь мы инициализируем map'у person, которая для ключей использует строковый тип, а для значений — пустой интерфейсный тип interface{}. Мы присвоили три разных типа в качестве значений map'ы (строковое, целочисленное и float32), и никаких проблем. Поскольку пустому интерфейсу удовлетворяют объекты любого типа, код работает замечательно.

Можете запустить этот код здесь, вы увидите подобный результат:

map[age:21 height:167.64 name:Alice]

Когда речь заходит об извлечении и использовании значений из map’ы, важно помнить вот о чём. Допустим, вы хотите получить значение age и увеличить его на 1. Если вы напишете подобный код, то он не скомпилируется:

package main

import "log"

func main() {
person := make(map[string]interface{}, 0)

person["name"] = "Alice"
person["age"] = 21
person["height"] = 167.64

person["age"] = person["age"] + 1

fmt.Printf("%+v", person)
}

Вы получите сообщение об ошибке:

invalid operation: person["age"] + 1 (mismatched types interface {} and int)

Причина в том, что значение, хранящееся в map, принимает тип interface{} и теряет свой исходный, базовый тип int. И поскольку значение больше не целочисленное, мы не можем прибавить к нему 1.

Чтобы это обойти, вам нужно сделать значение снова целочисленным, и только потом его использовать:

package main

import "log"

func main() {
person := make(map[string]interface{}, 0)

person["name"] = "Alice"
person["age"] = 21
person["height"] = 167.64

age, ok := person["age"].(int)
if !ok {
log.Fatal("could not assert value to int")
return
}

person["age"] = age + 1

log.Printf("%+v", person)
}

Если вы запустите это, все будет работать как полагается:

2009/11/10 23:00:00 map[age:22 height:167.64 name:Alice]

Так когда же следует использовать пустой интерфейсный тип?

Пожалуй, не слишком часто. Если вы к этому пришли, то остановитесь и подумайте, правильно ли сейчас использовать interface{}. В качестве общего совета могу сказать, что будет понятнее, безопаснее и производительнее использовать конкретные типы, то есть не пустые интерфейсные типы. В приведённом выше примере лучше было определить структуру Person с соответствующим образом типизированными полями:

type Person struct {
Name string
Age int
Height float32
}

С другой стороны, пустой интерфейс полезен в случаях, когда вам нужно обращаться и работать с непредсказуемыми или пользовательскими типами. Такие интерфейсы по определённым причинам используются в разных местах стандартной библиотеки, например, в функциях gob.Encode, fmt.Print и template.Execute.

Полезные интерфейсные типы

Вот короткий список самых востребованных и полезных интерфейсных типов из стандартной библиотеки. Если вы с ними ещё не знакомы, то рекомендую почитать соответствующую документацию.

builtin.Error

fmt.Stringer

io.Reader

io.Writer

io.ReadWriteCloser

http.ResponseWriter

http.Handler

Также здесь доступен более длинный список стандартных библиотек.

Теги:

go

golang

interfaces

никто не читает теги

Хабы:

Блог компании VK

Go

Анализ и проектирование систем

Программирование

Качество кода

+40

306

20
+20

VK
Технологии, которые объединяют

Подписаться

ВКонтактеTelegramХабр Карьера

391

Карма

0

Рейтинг

Макс @AloneCoder

¯\_(ツ)_/¯

Подписаться

Комментарии 20
+20

Публикации

Лучшие за суткиПохожие

A_Tsymbalyuk 13 часов назад

Физика ошибок не прощает. Она догонит тебя на крыше и заставит платить (дважды)

Простой

6 мин

8.1K

Кейс

+40

35

25
+25

BabayMazay 12 часов назад

Самодельные французские радиолампы 1920 г. Часть 3. Разрядная трубка, лампа накаливания

Средний

9 мин

1.5K

Ретроспектива

+33

9

11
+11

Bright_Translate 8 часов назад

Как я от нечего делать замедлил Postgres в 42 000 раз

Средний

11 мин

2.6K

Кейс

Перевод

+27

20

2
+2

butivskiydm 11 часов назад

Моё мини-исследование про продолжительность жизни в России

Простой

2 мин

6.4K

Мнение

+22

17

59
+59

lokosst 13 часов назад

Выделенные серверы + Kubernetes: практическое руководство

17 мин

2.4K

Туториал

+21

23

0

JetHabr 11 часов назад

«Плохие парни работают просто»: пентестеры разбирают реальные кейсы

Средний

9 мин

2.1K

Кейс

+17

19

1
+1

k0mar0v 14 часов назад

HTC Desire HD: история, дизайн и возможности легендарного смартфона

6 мин

2.6K

+17

5

5
+5

MaFrance351 13 часов назад

Домофон Лидер-М. Как скрестить трубку с ключом и всем проиграть

Простой

4 мин

2.5K

+16

5

6
+6

psygalaxy 13 часов назад

Как мы ускорили проверку документации с помощью AI-агента: от боли к решению

Средний

25 мин

1.8K

Обзор

+16

30

9
+9

kubelet 16 часов назад

DNS-хаос, зомби-поды и майнеры в кластере: самые невероятные случаи при работе с Kubernetes

Простой

14 мин

1.2K

Кейс

+16

23

1
+1

Показать еще

Информация

Сайт

team.vk.company

Дата регистрации

9 августа 2008

Дата основания

15 октября 1998

Численность

свыше 10 000 человек

Местоположение

Россия

Представитель

Дмитрий Головин

Виджет

Ищем бэкенд-разработчиков на Go и Java с опытом

продуктовой разработки от трёх лет

Приложения

ВКонтакте: музыка, видео, чаты

ВКонтакте объединяет десятки миллионов людей и даёт безграничные возможности для общения, знакомств, развлечений, бизнеса и обмена новостями из любой точки мира.

iOS

Android

Почта Mail.ru

Крупнейший в русскоязычном сегменте сети сервис бесплатной электронной почты.

iOS

Android

Облако Mail.ru

Бесплатный сервис для хранения фотографий, видео, музыки, презентаций, документов и других файлов.

iOS

Android

Маруся

Дружелюбный голосовой помощник.

iOS

Android

RuStore

Официальный магазин приложений для Android. Гарантированный безопасный доступ к приложениям

Android

VK Видео

Мобильное приложение VK Видео

Android

iOS

Дзен

Дзен — контентная платформа с актуальными новостями, видео и текстами от популярных блогеров.

iOS

Android

ВКонтакте

Блог на Хабре

30 июл в 14:46

Универсальная С++ фабрика объектов: для Qt и не только

4.6K

13
+13

29 июл в 12:21

Защита от невидимого шума: как ломать и чинить мультимодальные модели

937

0

28 июл в 11:04

LuaJIT: что делает его таким производительным и почему вам стоит его попробовать

5K

7
+7

25 июл в 15:37

PWA и WebView — как сделать ваше веб-приложение доступным в офлайн-режиме

4.6K

0

24 июл в 12:05

RuModernBERT и USER2: эволюция русскоязычных энкодеров

1.6K

4
+4

Ваш аккаунт

Профиль

Трекер

Диалоги

Настройки

ППА

Разделы

Статьи

Новости

Хабы

Компании

Авторы

Песочница

Информация

Устройство сайта

Для авторов

Для компаний

Документы

Соглашение

Конфиденциальность

Услуги

Корпоративный блог

Медийная реклама

Нативные проекты

Образовательные программы

Стартапам

Настройка языкаТехническая поддержка
© 2006–2025, Habr